Enhance crop zoning features with new API integrations and UI updates

- Added Persian translations for legend levels in fa.json to improve localization.
- Updated CROP_ZONING_APIS.md to include new API endpoints for water need, soil quality, and cultivation risk, enhancing data retrieval capabilities.
- Refactored crop zoning components to support new data structures and improve rendering logic for different layers.
- Enhanced LayerControl and ZoneLegend components to dynamically display information based on the active layer, improving user experience.
- Implemented loading states and error handling in CropZoningWrapper for better data management during asynchronous operations.
This commit is contained in:
2026-02-26 00:37:00 +03:30
parent 3db9a86cbf
commit 5aea10a756
7 changed files with 484 additions and 109 deletions
@@ -10,18 +10,24 @@
## نمای کلی و جریان درخواست‌ها
```
۱. GET area → منطقهٔ ثابت (کاربر امکان رسم ندارد)
۲. GET products → لیست محصولات و رنگ‌ها
۳. POST zones/initial → ارسال محدودهٔ مربع‌ها → دریافت دیتای اولیه (نقشه + هاور/tooltip)
۴. GET zone/:zoneId → کلیک روی مربع → دریافت دیتای تکمیلی (پنل جزئیات: reason, criteria, ...)
۱. GET area → منطقهٔ ثابت (کاربر امکان رسم ندارد)
۲. GET products → لیست محصولات و رنگ‌ها
۳. POST zones/initial → ارسال محدودهٔ مربع‌ها → دیتای محصولات پیشنهادی (نقشه + tooltip)
۴. POST zones/water-need → ارسال محدودهٔ مربع‌ها → نیاز آبی هر منطقه
۵. POST zones/soil-quality → ارسال محدودهٔ مربع‌ها → کیفیت خاک هر منطقه
۶. POST zones/cultivation-risk → ارسال محدودهٔ مربع‌ها → ریسک کشت هر منطقه
۷. GET zone/:zoneId → کلیک روی مربع → دیتای تکمیلی (پنل جزئیات: reason, criteria, ...)
```
| ردیف | API | هدف |
|------|-----|------|
| ۱ | **منطقهٔ اولیه** | دریافت منطقهٔ زمین به صورت GeoJSON؛ کاربر نمی‌تواند چیزی رسم کند |
| ۲ | **لیست محصولات و رنگ‌ها** | دریافت محصولات قابل کشت به همراه رنگ نمایش و لیبل فارسی |
| ۳ | **دیتای اولیه زون‌ها** | ارسال محدودهٔ مربع‌ها، دریافت دیتا برای نقشه و **هاور/tooltip** |
| ۴ | **دیتای تکمیلی زون** | با کلیک روی هر مربع، دریافت دیتای جزئیات (دلیل، معیارها، نمودار) |
| ۳ | **دیتای اولیه زون‌ها (محصولات)** | ارسال محدودهٔ مربع‌ها، دریافت محصول پیشنهادی برای نقشه و tooltip |
| ۴ | **نیاز آبی** | ارسال محدودهٔ مربع‌ها، دریافت نیاز آبی هر منطقه برای لایهٔ نیاز آبی |
| ۵ | **کیفیت خاک** | ارسال محدودهٔ مربع‌ها، دریافت کیفیت خاک هر منطقه برای لایهٔ کیفیت خاک |
| ۶ | **ریسک کشت** | ارسال محدودهٔ مربع‌ها، دریافت ریسک کشت هر منطقه برای لایهٔ ریسک کشت |
| ۷ | **دیتای تکمیلی زون** | با کلیک روی هر مربع، دریافت دیتای جزئیات (دلیل، معیارها، نمودار) |
---
@@ -241,15 +247,164 @@
|------|-----|--------|--------|
| `zoneId` | string | بله | شناسهٔ یکتا برای درخواست دیتای تکمیلی |
| `geometry` | Polygon | بله | هندسهٔ همان مربع ارسالی |
| `crop` | string | بله | محصول پیشنهادی (رنگ نقشه + tooltip) |
| `matchPercent` | number | بله | درصد تطابق (هاور/tooltip) |
| `waterNeed` | string | بله | نیاز آبی (هاور/tooltip) |
| `estimatedProfit` | string | بله | سود تخمینی (هاور/tooltip) |
| `crop` | string \| null | خیر | محصول پیشنهادی؛ اگر `null`/خالی/`uncultivable` باشد → زون **غیرقابل کشت** و رنگ خاکستری |
| `matchPercent` | number | خیر | درصد تطابق (هاور/tooltip) |
| `waterNeed` | string | خیر | نیاز آبی (هاور/tooltip) |
| `estimatedProfit` | string | خیر | سود تخمینی (هاور/tooltip) |
**زون غیرقابل کشت:** اگر برای مربعی اطلاعاتی نیاید یا `crop` خالی/`null`/`uncultivable` باشد، آن مربع خاکستری نمایش داده شده و در tooltip «غیر قابل کشت» نشان داده می‌شود. کلیک روی آن پنل جزئیات باز نمی‌شود.
**نکته:** این فیلدها هنگام **هاور** روی مربع در tooltip نمایش داده می‌شوند؛ نیازی به درخواست جداگانه برای tooltip نیست.
---
## ۲.۱ API نیاز آبی (Water Need)
نیاز آبی هر منطقه را بر اساس محدودهٔ مربع‌ها برمی‌گرداند. با تغییر لایه به «نیاز آبی» در LayerControl، فرانت این API را صدا می‌زند و نقشه و Legend را به‌روزرسانی می‌کند.
### مشخصات
- **متد:** `POST`
- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/water-need/`
- **هدف:** دریافت نیاز آبی هر منطقه برای نمایش روی نقشه در لایهٔ نیاز آبی.
### ورودی (Request Body)
همان ساختار `POST zones/initial/`:
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|--------|
| `zones` | GeoJSON FeatureCollection | بله | محدودهٔ هر مربع به صورت Polygon |
### خروجی (Response Body)
```json
{
"status": "success",
"data": {
"zones": [
{
"zoneId": "zone-0",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "low",
"value": "۳۰۰۰-۴۰۰۰ m³/ha",
"color": "#7dd3fc"
},
{
"zoneId": "zone-1",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "medium",
"value": "۵۰۰۰-۶۰۰۰ m³/ha",
"color": "#0ea5e9"
}
]
}
}
```
| فیلد | نوع | توضیح |
|------|-----|--------|
| `zoneId` | string | شناسهٔ زون |
| `geometry` | Polygon | هندسهٔ مربع |
| `level` | string | سطح: `low`, `medium`, `high` |
| `value` | string | مقدار نیاز آبی (مثلاً m³/ha) |
| `color` | string | رنگ hex برای نمایش |
---
## ۲.۲ API کیفیت خاک (Soil Quality)
کیفیت خاک هر منطقه را برمی‌گرداند. با تغییر لایه به «کیفیت خاک»، فرانت این API را صدا می‌زند.
### مشخصات
- **متد:** `POST`
- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/soil-quality/`
### ورودی (Request Body)
همان `zones` (FeatureCollection).
### خروجی (Response Body)
```json
{
"status": "success",
"data": {
"zones": [
{
"zoneId": "zone-0",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "low",
"score": 35,
"color": "#f87171"
},
{
"zoneId": "zone-1",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "high",
"score": 85,
"color": "#22c55e"
}
]
}
}
```
| فیلد | نوع | توضیح |
|------|-----|--------|
| `level` | string | سطح: `low`, `medium`, `high` |
| `score` | number | امتیاز ۰–۱۰۰ |
| `color` | string | رنگ hex |
---
## ۲.۳ API ریسک کشت (Cultivation Risk)
ریسک کشت هر منطقه را برمی‌گرداند. با تغییر لایه به «ریسک کشت»، فرانت این API را صدا می‌زند.
### مشخصات
- **متد:** `POST`
- **آدرس پیشنهادی:** `POST /api/crop-zoning/zones/cultivation-risk/`
### ورودی و خروجی
ورودی: همان `zones` (FeatureCollection).
خروجی نمونه:
```json
{
"status": "success",
"data": {
"zones": [
{
"zoneId": "zone-0",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "low",
"color": "#22c55e"
},
{
"zoneId": "zone-1",
"geometry": { "type": "Polygon", "coordinates": [...] },
"level": "high",
"color": "#ef4444"
}
]
}
}
```
| فیلد | نوع | توضیح |
|------|-----|--------|
| `level` | string | سطح: `low`, `medium`, `high` |
| `color` | string | رنگ hex |
**نکته:** برای هر لایه (نیاز آبی، کیفیت خاک، ریسک کشت) فرانت یک **درخواست جداگانه** ارسال می‌کند و نقشه و Legend متناسب با همان لایه به‌روزرسانی می‌شوند.
---
## ۳. API دیتای تکمیلی زون (با کلیک روی مربع)
وقتی کاربر روی یک مربع کلیک می‌کند، فرانت با `zoneId` دیتای **تکمیلی** را درخواست می‌کند — برای نمایش پنل جزئیات: دلیل پیشنهاد، معیارها، نمودار راداری.
@@ -369,8 +524,13 @@ const CROP_COLORS: Record<CropType, string> = {
## ۶. جریان فرانت با APIها
1. **لود صفحه:** `GET /api/crop-zoning/products/` → لیست محصولات و رنگ‌ها.
2. **رسم منطقه / بهینه‌سازی:** فرانت با Turf از polygon منطقه گرید می‌سازد → `POST /api/crop-zoning/zones/initial/` با `zones` (FeatureCollection) → نقشه و هاور/tooltip با دیتای اولیه رسم می‌شود.
3. **کلیک روی مربع:** `GET /api/crop-zoning/zones/{zoneId}/details/` → دیتای تکمیلی → پنل جزئیات باز می‌شود (reason, criteria, نمودار راداری).
2. **رسم منطقه / بهینه‌سازی:** فرانت با Turf از polygon منطقه گرید می‌سازد → `POST /api/crop-zoning/zones/initial/` با `zones` (FeatureCollection) → نقشه و tooltip با دیتای محصولات رسم می‌شود.
3. **تغییر لایه در LayerControl:** برای هر لایه یک درخواست جداگانه ارسال می‌شود:
- محصولات پیشنهادی: `POST zones/initial/` (در مرحلهٔ ۲)
- نیاز آبی: `POST zones/water-need/` → نقشه و Legend به‌روزرسانی می‌شوند
- کیفیت خاک: `POST zones/soil-quality/` → نقشه و Legend به‌روزرسانی می‌شوند
- ریسک کشت: `POST zones/cultivation-risk/` → نقشه و Legend به‌روزرسانی می‌شوند
4. **کلیک روی مربع:** `GET /api/crop-zoning/zones/{zoneId}/details/` → دیتای تکمیلی → پنل جزئیات باز می‌شود (reason, criteria, نمودار راداری).
---
+67 -4
View File
@@ -17,10 +17,11 @@ export interface Product {
export interface ZoneInitialData {
zoneId: string
geometry: Polygon
crop: string
matchPercent: number
waterNeed: string
estimatedProfit: string
/** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده می‌شود */
crop?: string | null
matchPercent?: number | null
waterNeed?: string | null
estimatedProfit?: string | null
}
export interface ZonesInitialResponse {
@@ -45,6 +46,42 @@ export interface ZoneDetailData {
area_hectares?: number
}
/** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */
export interface ZoneWaterNeedData {
zoneId: string
geometry: Polygon
level: 'low' | 'medium' | 'high'
value?: string
color: string
}
/** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */
export interface ZoneSoilQualityData {
zoneId: string
geometry: Polygon
level: 'low' | 'medium' | 'high'
score?: number
color: string
}
/** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */
export interface ZoneCultivationRiskData {
zoneId: string
geometry: Polygon
level: 'low' | 'medium' | 'high'
color: string
}
/** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایه‌ها */
export interface ZoneMapData {
zoneId: string
geometry: Polygon
color: string
tooltipContent: string
cultivable: boolean
zoneInitialData?: ZoneInitialData
}
interface ApiResponse<T> {
status: string
data: T
@@ -73,5 +110,31 @@ export const cropZoningService = {
getArea(): Promise<AreaResponse> {
return unwrap(apiClient.get<ApiResponse<AreaResponse>>(`${PREFIX}/area/`))
},
/** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */
getZonesWaterNeed(body: { zones: FeatureCollection<Polygon> }): Promise<{ zones: ZoneWaterNeedData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneWaterNeedData[] }>>(`${PREFIX}/zones/water-need/`, body)
)
},
/** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */
getZonesSoilQuality(body: { zones: FeatureCollection<Polygon> }): Promise<{ zones: ZoneSoilQualityData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(`${PREFIX}/zones/soil-quality/`, body)
)
},
/** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */
getZonesCultivationRisk(body: {
zones: FeatureCollection<Polygon>
}): Promise<{ zones: ZoneCultivationRiskData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneCultivationRiskData[] }>>(
`${PREFIX}/zones/cultivation-risk/`,
body
)
)
}
}
@@ -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 { CROP_COLORS, type CropType, type LayerType, type ZoneFeatureProperties } from './cropZoningTypes'
import type { ZoneInitialData } from '@/libs/api/services/cropZoningService'
import type { LayerType } from './cropZoningTypes'
import type { ZoneMapData, ZoneInitialData } from '@/libs/api/services/cropZoningService'
export type MapDrawGeoJSON = Record<string, unknown> | null
@@ -21,19 +21,14 @@ type CropZoningMapProps = {
className?: string
/** منطقهٔ اولیه؛ وقتی مقدار دارد و readOnly باشد نقشه فقط نمایشی است */
initialAreaGeoJson?: MapDrawGeoJSON | null
/** دیتای زون‌ها از API (POST zones/initial) */
zonesData?: ZoneInitialData[] | null
/** لیبل محصولات از API (id -> label) برای tooltip */
/** دیتای زون‌ها برای نقشه — از APIهای zones/initial یا water-need یا soil-quality یا cultivation-risk */
zonesData?: ZoneMapData[] | null
/** لیبل محصولات از API (id -> label) — فقط برای لایهٔ crops */
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],
@@ -46,7 +41,7 @@ export default function CropZoningMap({
className = '',
initialAreaGeoJson = null,
zonesData = null,
productLabels = DEFAULT_PRODUCT_LABELS,
productLabels = {},
readOnly = false
}: CropZoningMapProps) {
const mapRef = useRef<HTMLDivElement>(null)
@@ -56,14 +51,13 @@ export default function CropZoningMap({
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
const renderZonesFromApi = useCallback(
(map: L.Map, zones: ZoneInitialData[]) => {
(map: L.Map, zones: ZoneMapData[]) => {
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 => ({
@@ -71,10 +65,10 @@ export default function CropZoningMap({
geometry: z.geometry,
properties: {
zoneId: z.zoneId,
crop: z.crop,
matchPercent: z.matchPercent,
waterNeed: z.waterNeed,
estimatedProfit: z.estimatedProfit
color: z.color,
tooltipContent: z.tooltipContent,
cultivable: z.cultivable,
zoneInitialData: z.zoneInitialData
}
}))
}
@@ -83,48 +77,31 @@ export default function CropZoningMap({
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' }
}
style: (feature?: { properties?: { color?: string } }) => {
const color = feature?.properties?.color ?? '#94a3b8'
return {
fillColor: (CROP_COLORS[props.crop as CropType] ?? '#94a3b8'),
fillColor: color,
fillOpacity: 0.5,
weight: 1,
color: '#fff'
}
},
onEachFeature: (feat: { geometry?: unknown; properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
const feature = feat as { geometry: Polygon; properties?: ZoneFeatureProperties }
const props = feature?.properties
const geom = feature?.geometry
onEachFeature: (feat: unknown, leafLayer: L.Layer) => {
const f = feat as { geometry?: Polygon; properties?: Record<string, unknown> }
const props = f?.properties
const geom = f?.geometry
if (!props || !geom) return
const layer = leafLayer as L.Polygon
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>
<div>درصد تطابق: ${props.matchPercent}%</div>
<div>نیاز آب: ${props.waterNeed}</div>
<div>سود تخمینی: ${props.estimatedProfit}</div>
</div>
`
const tooltipContent = (props.tooltipContent as string) ?? ''
const cultivable = props.cultivable === true
const zoneInitialData = props.zoneInitialData as ZoneInitialData | undefined
layer.bindTooltip(tooltipContent, {
sticky: true,
className: 'zone-tooltip',
direction: 'top',
offset: [0, -8]
})
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))
layer.on('click', () => cultivable && zoneInitialData && onZoneClick?.(zoneInitialData))
}
})
@@ -143,7 +120,7 @@ export default function CropZoningMap({
}, delay)
})
},
[activeLayer, onZoneClick, productLabels]
[onZoneClick]
)
useEffect(() => {
@@ -268,23 +245,6 @@ export default function CropZoningMap({
}
}, [zonesData, optimizationKey, renderZonesFromApi])
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
@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
@@ -12,7 +12,17 @@ import LayerControl from './LayerControl'
import ZoneDetailPanel from './ZoneDetailPanel'
import CropZoningWeatherSection from './CropZoningWeatherSection'
import { createGridFromPolygon } from './cropZoningUtils'
import { cropZoningService, type Product, type ZoneInitialData, type ZoneDetailData } from '@/libs/api/services/cropZoningService'
import {
cropZoningService,
type Product,
type ZoneInitialData,
type ZoneDetailData,
type ZoneMapData,
type ZoneWaterNeedData,
type ZoneSoilQualityData,
type ZoneCultivationRiskData
} from '@/libs/api/services/cropZoningService'
import { CROP_COLORS, type CropType } from './cropZoningTypes'
import type { LayerType } from './cropZoningTypes'
import type { MapDrawGeoJSON } from './CropZoningMap'
@@ -34,9 +44,13 @@ export default function CropZoningWrapper() {
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
const [areaLoading, setAreaLoading] = useState(true)
const [zonesData, setZonesData] = useState<ZoneInitialData[] | null>(null)
const [zonesWaterNeed, setZonesWaterNeed] = useState<ZoneWaterNeedData[] | null>(null)
const [zonesSoilQuality, setZonesSoilQuality] = useState<ZoneSoilQualityData[] | null>(null)
const [zonesCultivationRisk, setZonesCultivationRisk] = useState<ZoneCultivationRiskData[] | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [productsLoading, setProductsLoading] = useState(true)
const [zonesLoading, setZonesLoading] = useState(false)
const [layerDataLoading, setLayerDataLoading] = useState(false)
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
const [selectedZone, setSelectedZone] = useState<ZoneDetailData | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
@@ -65,8 +79,14 @@ export default function CropZoningWrapper() {
const fetchZones = useCallback((geojson: MapDrawGeoJSON) => {
if (!isPolygon(geojson)) {
setZonesData(null)
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
return
}
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
setZonesLoading(true)
const grid = createGridFromPolygon(geojson as unknown as import('geojson').Feature<import('geojson').Polygon>)
cropZoningService
@@ -81,9 +101,113 @@ export default function CropZoningWrapper() {
fetchZones(areaGeoJson)
} else {
setZonesData(null)
setZonesWaterNeed(null)
setZonesSoilQuality(null)
setZonesCultivationRisk(null)
}
}, [areaGeoJson, optimizationKey, fetchZones])
const gridForLayers = isPolygon(areaGeoJson)
? createGridFromPolygon(areaGeoJson as unknown as import('geojson').Feature<import('geojson').Polygon>)
: null
useEffect(() => {
if (!gridForLayers || zonesLoading) return
if (activeLayer === 'waterNeed' && zonesWaterNeed === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesWaterNeed({ zones: gridForLayers })
.then(res => setZonesWaterNeed(res.zones))
.catch(() => setZonesWaterNeed(null))
.finally(() => setLayerDataLoading(false))
} else if (activeLayer === 'soilQuality' && zonesSoilQuality === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesSoilQuality({ zones: gridForLayers })
.then(res => setZonesSoilQuality(res.zones))
.catch(() => setZonesSoilQuality(null))
.finally(() => setLayerDataLoading(false))
} else if (activeLayer === 'cultivationRisk' && zonesCultivationRisk === null) {
setLayerDataLoading(true)
cropZoningService
.getZonesCultivationRisk({ zones: gridForLayers })
.then(res => setZonesCultivationRisk(res.zones))
.catch(() => setZonesCultivationRisk(null))
.finally(() => setLayerDataLoading(false))
}
}, [activeLayer, gridForLayers, zonesLoading, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk])
const mapZonesData = useMemo((): ZoneMapData[] | null => {
const labels = Object.fromEntries(products.map(p => [p.id, p.label]))
if (activeLayer === 'crops' && zonesData) {
const isCultivable = (crop: string | null | undefined) =>
!!crop && crop !== 'uncultivable' && crop.toLowerCase() !== 'uncultivable'
return zonesData.map(z => {
const cultivable = isCultivable(z.crop)
const cropLabel = cultivable ? (labels[z.crop!] ?? z.crop) : 'غیر قابل کشت'
const tooltipContent = cultivable
? `<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>درصد تطابق: ${z.matchPercent ?? '-'}%</div>
<div>نیاز آب: ${z.waterNeed ?? '-'}</div>
<div>سود تخمینی: ${z.estimatedProfit ?? '-'}</div>
</div>`
: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px; color: #64748b;">غیر قابل کشت</div>
<div style="color: #94a3b8;">این بخش برای کشت مناسب تشخیص داده نشده است.</div>
</div>`
const color = cultivable ? (CROP_COLORS[z.crop as CropType] ?? '#94a3b8') : '#94a3b8'
return {
zoneId: z.zoneId,
geometry: z.geometry,
color,
tooltipContent,
cultivable,
zoneInitialData: z
}
})
}
if (activeLayer === 'waterNeed' && zonesWaterNeed) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesWaterNeed.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">نیاز آبی: ${levelLabels[z.level]}</div>
<div>${z.value ?? '-'}</div>
</div>`,
cultivable: false
}))
}
if (activeLayer === 'soilQuality' && zonesSoilQuality) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesSoilQuality.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">کیفیت خاک: ${levelLabels[z.level]}</div>
<div>امتیاز: ${z.score ?? '-'}</div>
</div>`,
cultivable: false
}))
}
if (activeLayer === 'cultivationRisk' && zonesCultivationRisk) {
const levelLabels = { low: 'کم', medium: 'متوسط', high: 'زیاد' }
return zonesCultivationRisk.map(z => ({
zoneId: z.zoneId,
geometry: z.geometry,
color: z.color,
tooltipContent: `<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">ریسک کشت: ${levelLabels[z.level]}</div>
</div>`,
cultivable: false
}))
}
return null
}, [activeLayer, zonesData, zonesWaterNeed, zonesSoilQuality, zonesCultivationRisk, products])
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
setAreaGeoJson(geojson)
}, [])
@@ -119,7 +243,7 @@ export default function CropZoningWrapper() {
optimizationKey={optimizationKey}
className='min-bs-[400px]'
initialAreaGeoJson={areaGeoJson}
zonesData={zonesData}
zonesData={mapZonesData}
productLabels={productLabels}
readOnly
/>
@@ -128,7 +252,7 @@ export default function CropZoningWrapper() {
)}
</Box>
{(areaLoading || zonesLoading) && (
{(areaLoading || zonesLoading || (activeLayer !== 'crops' && layerDataLoading)) && (
<Box
className='absolute inset-0 z-[500] flex items-center justify-center bg-white/60 dark:bg-gray-900/60'
sx={{ borderRadius: 12 }}
@@ -139,7 +263,11 @@ export default function CropZoningWrapper() {
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
<ZoneLegend products={products} loading={productsLoading} />
<ZoneLegend
activeLayer={activeLayer}
products={products}
loading={productsLoading || (activeLayer !== 'crops' && layerDataLoading)}
/>
{areaGeoJson && (
<Box className='absolute top-16 end-4 z-[1000]'>
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'
import IconButton from '@mui/material/IconButton'
import { useTheme, alpha } from '@mui/material/styles'
import type { LayerType } from './cropZoningTypes'
const LAYER_ICONS: Record<LayerType, string> = {
@@ -20,24 +21,34 @@ const LAYER_ORDER: LayerType[] = ['crops', 'waterNeed', 'soilQuality', 'cultivat
export default function LayerControl({ activeLayer, onLayerChange }: LayerControlProps) {
const t = useTranslations('cropZoning')
const theme = useTheme()
const primaryMain = theme.palette.primary.main
return (
<div className='absolute bottom-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>
))}
{LAYER_ORDER.map(layer => {
const isActive = activeLayer === layer
return (
<IconButton
key={layer}
size='small'
onClick={() => onLayerChange(layer)}
title={t(`layers.${layer}`)}
sx={{
mx: 0.5,
bgcolor: isActive ? alpha(primaryMain, 0.2) : 'transparent',
color: isActive ? primaryMain : 'text.secondary',
border: isActive ? `1px solid ${alpha(primaryMain, 0.5)}` : '1px solid transparent',
'&:hover': {
bgcolor: isActive ? alpha(primaryMain, 0.25) : 'action.hover',
color: isActive ? primaryMain : undefined
}
}}
>
<i className={`${LAYER_ICONS[layer]} text-lg`} />
</IconButton>
)
})}
</div>
)
}
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'
import { CROP_COLORS, type CropType } from './cropZoningTypes'
import type { Product } from '@/libs/api/services/cropZoningService'
import type { LayerType } from './cropZoningTypes'
const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [
{ crop: 'wheat', label: 'گندم' },
@@ -10,16 +11,66 @@ const FALLBACK_ITEMS: { crop: CropType; label: string }[] = [
{ crop: 'saffron', label: 'زعفران' }
]
const WATER_NEED_ITEMS = [
{ level: 'low' as const, color: '#7dd3fc', labelKey: 'low' },
{ level: 'medium' as const, color: '#0ea5e9', labelKey: 'medium' },
{ level: 'high' as const, color: '#0369a1', labelKey: 'high' }
]
const SOIL_QUALITY_ITEMS = [
{ level: 'low' as const, color: '#f87171', labelKey: 'low' },
{ level: 'medium' as const, color: '#fbbf24', labelKey: 'medium' },
{ level: 'high' as const, color: '#22c55e', labelKey: 'high' }
]
const CULTIVATION_RISK_ITEMS = [
{ level: 'low' as const, color: '#22c55e', labelKey: 'low' },
{ level: 'medium' as const, color: '#f97316', labelKey: 'medium' },
{ level: 'high' as const, color: '#ef4444', labelKey: 'high' }
]
type ZoneLegendProps = {
activeLayer?: LayerType
products?: Product[]
loading?: boolean
}
export default function ZoneLegend({ products = [], loading = false }: ZoneLegendProps) {
export default function ZoneLegend({ activeLayer = 'crops', products = [], loading = false }: ZoneLegendProps) {
const t = useTranslations('cropZoning')
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] }))
const items =
activeLayer === 'waterNeed'
? WATER_NEED_ITEMS.map(({ level, color, labelKey }) => ({
key: `water-${level}`,
label: t(`legendLevels.${labelKey}`),
color
}))
: activeLayer === 'soilQuality'
? SOIL_QUALITY_ITEMS.map(({ level, color, labelKey }) => ({
key: `soil-${level}`,
label: t(`legendLevels.${labelKey}`),
color
}))
: activeLayer === 'cultivationRisk'
? CULTIVATION_RISK_ITEMS.map(({ level, color, labelKey }) => ({
key: `risk-${level}`,
label: t(`legendLevels.${labelKey}`),
color
}))
: (() => {
const productItems =
products.length > 0
? products.map(p => ({ key: p.id, label: p.label, color: p.color }))
: FALLBACK_ITEMS.map(({ crop }) => ({
key: crop,
label: t(`crops.${crop}`),
color: CROP_COLORS[crop]
}))
return [
...productItems,
{ key: 'uncultivable', label: 'غیر قابل کشت', color: '#94a3b8' }
]
})()
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'>
@@ -28,12 +79,9 @@ export default function ZoneLegend({ products = [], loading = false }: ZoneLegen
<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 }}
/>
{items.map(({ key, label, color }) => (
<div key={key} className='flex items-center gap-2'>
<div className='h-4 w-4 rounded' style={{ backgroundColor: color, opacity: 0.8 }} />
<span className='text-sm text-gray-700 dark:text-gray-300'>{label}</span>
</div>
))}