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:
@@ -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, نمودار راداری).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user