Refactor CropZoningMap and related components for improved API integration and UI enhancements

- Updated CropZoningMap to utilize new ZoneInitialData type for zone click handling and added zonesData prop for API-driven zone rendering.
- Removed deprecated crop zoning mock data file and integrated grid creation logic for initial zone fetching.
- Enhanced CropZoningWrapper to manage area and zone data loading states, improving user experience with asynchronous data fetching.
- Updated ZoneDetailPanel to handle loading states and display product labels dynamically based on fetched data.
- Refactored ZoneLegend to conditionally render items based on available product data, enhancing visual feedback during loading.
This commit is contained in:
2026-02-26 00:17:32 +03:30
parent aad5b1c2bd
commit 3db9a86cbf
9 changed files with 981 additions and 158 deletions
@@ -5,8 +5,8 @@ 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'
import type { ZoneInitialData } from '@/libs/api/services/cropZoningService'
export type MapDrawGeoJSON = Record<string, unknown> | null
@@ -16,15 +16,25 @@ type CropZoningMapProps = {
height?: string | number
activeLayer: LayerType
onAreaChange?: (geojson: MapDrawGeoJSON) => void
onZoneClick?: (zone: ZoneFeatureProperties) => void
onZoneClick?: (zone: ZoneInitialData) => void
optimizationKey?: number
className?: string
/** منطقهٔ اولیه از دیتای ماک؛ وقتی مقدار دارد نقشه فقط نمایشی است و کاربر نمیتواند منطقه را تغییر دهد */
/** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */
initialAreaGeoJson?: MapDrawGeoJSON | null
/** دیتای زون‌ها از API (POST zones/initial) */
zonesData?: ZoneInitialData[] | null
/** لیبل محصولات از API (id -> label) برای tooltip */
productLabels?: Record<string, string>
/** غیرفعال کردن رسم و ویرایش منطقه توسط کاربر */
readOnly?: boolean
}
const DEFAULT_PRODUCT_LABELS: Record<string, string> = {
wheat: 'گندم',
canola: 'کلزا',
saffron: 'زعفران'
}
export default function CropZoningMap({
center = [35.6892, 51.389],
zoom = 13,
@@ -35,6 +45,8 @@ export default function CropZoningMap({
optimizationKey = 0,
className = '',
initialAreaGeoJson = null,
zonesData = null,
productLabels = DEFAULT_PRODUCT_LABELS,
readOnly = false
}: CropZoningMapProps) {
const mapRef = useRef<HTMLDivElement>(null)
@@ -43,14 +55,30 @@ export default function CropZoningMap({
const drawControlRef = useRef<L.Control.Draw | null>(null)
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
const renderZones = useCallback(
(map: L.Map, polygonFeature: Feature<Polygon>) => {
const renderZonesFromApi = useCallback(
(map: L.Map, zones: ZoneInitialData[]) => {
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
if (zones.length === 0) return
const labels = { ...DEFAULT_PRODUCT_LABELS, ...productLabels }
const grid = {
type: 'FeatureCollection' as const,
features: zones.map(z => ({
type: 'Feature' as const,
geometry: z.geometry,
properties: {
zoneId: z.zoneId,
crop: z.crop,
matchPercent: z.matchPercent,
waterNeed: z.waterNeed,
estimatedProfit: z.estimatedProfit
}
}))
}
const grid = createZonedGrid(polygonFeature)
const L = (window as unknown as { L: typeof import('leaflet') }).L
if (!L) return
@@ -61,17 +89,19 @@ export default function CropZoningMap({
return { fillColor: '#94a3b8', fillOpacity: 0.5, weight: 1, color: '#fff' }
}
return {
fillColor: CROP_COLORS[props.crop as CropType],
fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'),
fillOpacity: 0.5,
weight: 1,
color: '#fff'
}
},
onEachFeature: (feature: { properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
onEachFeature: (feat: { geometry?: unknown; properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
const feature = feat as { geometry: Polygon; properties?: ZoneFeatureProperties }
const props = feature?.properties
if (!props) return
const geom = feature?.geometry
if (!props || !geom) return
const layer = leafLayer as L.Polygon
const cropLabel = props.crop === 'wheat' ? 'گندم' : props.crop === 'canola' ? 'کلزا' : 'زعفران'
const cropLabel = labels[props.crop] ?? props.crop
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>
@@ -86,7 +116,15 @@ export default function CropZoningMap({
direction: 'top',
offset: [0, -8]
})
layer.on('click', () => onZoneClick?.(props))
const zoneData: ZoneInitialData = {
zoneId: props.zoneId,
geometry: geom,
crop: props.crop,
matchPercent: props.matchPercent,
waterNeed: props.waterNeed,
estimatedProfit: props.estimatedProfit
}
layer.on('click', () => onZoneClick?.(zoneData))
}
})
@@ -105,7 +143,7 @@ export default function CropZoningMap({
}, delay)
})
},
[activeLayer, onZoneClick]
[activeLayer, onZoneClick, productLabels]
)
useEffect(() => {
@@ -155,12 +193,10 @@ export default function CropZoningMap({
return null
}
const emitAndRender = () => {
const emitAreaChange = () => {
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) {
if (!geojson && zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
@@ -168,18 +204,18 @@ export default function CropZoningMap({
if (readOnly && initialAreaGeoJson && initialAreaGeoJson.geometry && (initialAreaGeoJson.geometry as { type: string }).type === 'Polygon') {
drawnItems.clearLayers()
L.geoJSON(initialAreaGeoJson as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
emitAndRender()
L.geoJSON(initialAreaGeoJson as unknown as Feature<Polygon>).eachLayer((layer) => drawnItems.addLayer(layer))
emitAreaChange()
}
const onCreated = (e: L.LeafletEvent) => {
const event = e as L.DrawEvents.Created
drawnItems.clearLayers()
drawnItems.addLayer(event.layer)
emitAndRender()
emitAreaChange()
}
const onEdited = () => emitAndRender()
const onEdited = () => emitAreaChange()
const onDeleted = () => {
onAreaChange?.(null)
if (zonesLayerRef.current) {
@@ -223,13 +259,14 @@ export default function CropZoningMap({
}, [])
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)
if (!mapInstanceRef.current) return
if (zonesData && zonesData.length > 0) {
renderZonesFromApi(mapInstanceRef.current, zonesData)
} else if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}, [optimizationKey, activeLayer, renderZones])
}, [zonesData, optimizationKey, renderZonesFromApi])
useEffect(() => {
const zonesLayer = zonesLayerRef.current
@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
@@ -11,9 +11,9 @@ import ZoneLegend from './ZoneLegend'
import LayerControl from './LayerControl'
import ZoneDetailPanel from './ZoneDetailPanel'
import CropZoningWeatherSection from './CropZoningWeatherSection'
import { MOCK_AREA_GEOJSON } from './cropZoningMockData'
import { createGridFromPolygon } from './cropZoningUtils'
import { cropZoningService, type Product, type ZoneInitialData, type ZoneDetailData } from '@/libs/api/services/cropZoningService'
import type { LayerType } from './cropZoningTypes'
import type { ZoneFeatureProperties } from './cropZoningTypes'
import type { MapDrawGeoJSON } from './CropZoningMap'
const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
@@ -25,21 +25,78 @@ const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
)
})
function isPolygon(geojson: MapDrawGeoJSON): geojson is MapDrawGeoJSON & { geometry: { type: 'Polygon'; coordinates: unknown[] } } {
return !!geojson?.geometry && (geojson.geometry as { type: string }).type === 'Polygon'
}
export default function CropZoningWrapper() {
const t = useTranslations('cropZoning')
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(MOCK_AREA_GEOJSON)
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
const [areaLoading, setAreaLoading] = useState(true)
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [productsLoading, setProductsLoading] = useState(true)
const [zonesLoading, setZonesLoading] = useState(false)
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
const [selectedZone, setSelectedZone] = useState<ZoneFeatureProperties | null>(null)
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
const [zoneDetailLoading, setZoneDetailLoading] = useState(false)
const [optimizationKey, setOptimizationKey] = useState(0)
const productLabels = Object.fromEntries(products.map(p => [p.id, p.label]))
useEffect(() => {
cropZoningService
.getProducts()
.then(res => setProducts(res.products))
.catch(() => setProducts([]))
.finally(() => setProductsLoading(false))
}, [])
useEffect(() => {
setAreaLoading(true)
cropZoningService
.getArea()
.then(res => setAreaGeoJson(res.area as unknown as MapDrawGeoJSON))
.catch(() => setAreaGeoJson(null))
.finally(() => setAreaLoading(false))
}, [])
const fetchZones = useCallback((geojson: MapDrawGeoJSON) => {
if (!isPolygon(geojson)) {
setZonesData(null)
return
}
setZonesLoading(true)
const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature<import('geojson').Polygon>)
cropZoningService
.getZonesInitial({ zones: grid })
.then(res => setZonesData(res.zones))
.catch(() => setZonesData(null))
.finally(() => setZonesLoading(false))
}, [])
useEffect(() => {
if (isPolygon(areaGeoJson)) {
fetchZones(areaGeoJson)
} else {
setZonesData(null)
}
}, [areaGeoJson, optimizationKey, fetchZones])
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
setAreaGeoJson(geojson)
}, [])
const handleZoneClick = useCallback((zone: ZoneFeatureProperties) => {
setSelectedZone(zone)
const handleZoneClick = useCallback((zone: ZoneInitialData) => {
setZoneDetailLoading(true)
setPanelOpen(true)
setSelectedZone(null)
cropZoningService
.getZoneDetails(zone.zoneId)
.then(details => setSelectedZone(details))
.catch(() => setSelectedZone(null))
.finally(() => setZoneDetailLoading(false))
}, [])
const handleOptimize = useCallback(() => {
@@ -50,46 +107,64 @@ export default function CropZoningWrapper() {
<Box className='flex flex-col gap-6 is-full'>
<Box className='relative min-bs-[400px] rounded-xl overflow-hidden' sx={{ height: 'min(60vh, 500px)' }}>
<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]'
initialAreaGeoJson={MOCK_AREA_GEOJSON}
readOnly
/>
</Box>
{areaGeoJson ? (
<MapComponent
key='crop-zoning-map'
center={[35.6892, 51.389]}
zoom={13}
height='100%'
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
initialAreaGeoJson={areaGeoJson}
zonesData={zonesData}
productLabels={productLabels}
readOnly
/>
) : (
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover' />
)}
</Box>
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
{(areaLoading || zonesLoading) && (
<Box
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
sx={{ borderRadius: 12 }}
>
<CircularProgress size={48} />
</Box>
)}
{areaGeoJson && (
<>
<ZoneLegend />
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
<ZoneLegend products={products} loading={productsLoading} />
{areaGeoJson && (
<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'
variant='contained'
color='primary'
size='medium'
startIcon={<i className='tabler-refresh text-xl' />}
onClick={handleOptimize}
disabled={zonesLoading}
className='rounded-xl shadow-lg'
>
{t('optimizeAgain')}
</Button>
</Box>
</>
)}
)}
</Box>
<ZoneDetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
zone={selectedZone}
products={products}
loading={zoneDetailLoading}
/>
</Box>
<CropZoningWeatherSection />
</Box>
@@ -14,6 +14,7 @@ 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 CircularProgress from '@mui/material/CircularProgress'
import {
Radar,
RadarChart,
@@ -22,17 +23,20 @@ import {
PolarRadiusAxis,
ResponsiveContainer
} from 'recharts'
import type { ZoneFeatureProperties } from './cropZoningTypes'
import { CROP_COLORS } from './cropZoningTypes'
import type { Product } from '@/libs/api/services/cropZoningService'
import type { ZoneDetailData } from '@/libs/api/services/cropZoningService'
type ZoneDetailPanelProps = {
open: boolean
onClose: () => void
zone: ZoneFeatureProperties | null
zone: ZoneDetailData | null
products?: Product[]
loading?: boolean
onCropChange?: (zoneId: string, crop: string) => void
}
const CROP_LABELS: Record<string, string> = {
const FALLBACK_LABELS: Record<string, string> = {
wheat: 'گندم',
canola: 'کلزا',
saffron: 'زعفران'
@@ -42,6 +46,8 @@ export default function ZoneDetailPanel({
open,
onClose,
zone,
products = [],
loading = false,
onCropChange
}: ZoneDetailPanelProps) {
const t = useTranslations('cropZoning')
@@ -49,9 +55,13 @@ export default function ZoneDetailPanel({
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
if (!zone) return null
const cropLabels = products.length > 0
? Object.fromEntries(products.map(p => [p.id, p.label]))
: FALLBACK_LABELS
const chartData = zone.criteria.map(c => ({ subject: c.name, value: c.value, fullMark: 100 }))
if (!open) return null
const chartData = zone?.criteria?.map(c => ({ subject: c.name, value: c.value, fullMark: 100 })) ?? []
return (
<Drawer
@@ -78,62 +88,84 @@ export default function ZoneDetailPanel({
</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>
{loading ? (
<Box className='flex justify-center py-12'>
<CircularProgress />
</Box>
) : zone ? (
<>
<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] ?? undefined }}>
{cropLabels[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>
<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>
{chartData.length > 0 && (
<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>
<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)}
>
{products.length > 0
? products.map(p => (
<MenuItem key={p.id} value={p.id}>
{p.label}
</MenuItem>
))
: Object.entries(FALLBACK_LABELS).map(([id, label]) => (
<MenuItem key={id} value={id}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</>
) : (
<Typography variant='body2' color='text.secondary'>
اطلاعاتی یافت نشد.
</Typography>
)}
<Button variant='outlined' onClick={onClose} fullWidth>
{tCommon('close')}
@@ -2,30 +2,43 @@
import { useTranslations } from 'next-intl'
import { CROP_COLORS, type CropType } from './cropZoningTypes'
import type { Product } from '@/libs/api/services/cropZoningService'
export default function ZoneLegend() {
const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [
{ crop: 'wheat', label: 'گندم' },
{ crop: 'canola', label: 'کلزا' },
{ crop: 'saffron', label: 'زعفران' }
]
type ZoneLegendProps = {
products?: Product[]
loading?: boolean
}
export default function ZoneLegend({ products = [], loading = false }: ZoneLegendProps) {
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') }
]
const items = products.length > 0
? products.map(p => ({ crop: p.id as CropType, label: p.label, color: p.color }))
: FALLBACK_ITEMS.map(({ crop }) => ({ crop, label: t(`crops.${crop}`), color: CROP_COLORS[crop] }))
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>
{loading ? (
<div className='text-sm text-gray-500'>...</div>
) : (
<div className='flex flex-col gap-1.5'>
{items.map(({ crop, label, color }) => (
<div key={crop} className='flex items-center gap-2'>
<div
className='h-4 w-4 rounded'
style={{ backgroundColor: color ?? CROP_COLORS[crop as CropType] ?? '#94a3b8', opacity: 0.8 }}
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>{label}</span>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,23 +0,0 @@
import type { MapDrawGeoJSON } from './CropZoningMap'
/**
* منطقهٔ ثابت برای نمایش روی نقشه (دیتای ماک).
* کاربر امکان تغییر یا رسم منطقهٔ جدید را ندارد.
* مختصات: یک چندضلعی حول تهران [35.6892, 51.389] — در GeoJSON به صورت [lng, lat].
*/
export const MOCK_AREA_GEOJSON: MapDrawGeoJSON = {
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [
[
[51.38, 35.68],
[51.40, 35.68],
[51.40, 35.70],
[51.38, 35.70],
[51.38, 35.68]
]
]
}
} as MapDrawGeoJSON
@@ -78,3 +78,25 @@ export function createZonedGrid(
features
}
}
/**
* Creates grid geometry only (no crop assignment) for sending to POST /api/crop-zoning/zones/initial/
*/
export function createGridFromPolygon(
polygonFeature: Feature<Polygon>,
cellSideKm = 0.15
): FeatureCollection<Polygon, { index?: number }> {
const bboxArr = bbox(polygonFeature)
const grid = squareGrid(bboxArr, cellSideKm, {
units: 'kilometers',
mask: polygonFeature
})
const features = grid.features.map((f, i) => ({
...f,
properties: { index: i }
}))
return {
type: 'FeatureCollection',
features
}
}